覆盖率平台开发实践
背景
作为一个测试人员,保证产品的软件质量是其工作首要目标,为了这个目标,测试人员常常会通过很多手段或工具来加以保证,而覆盖率就是其中比较重要的一个环节。
覆盖率是用来度量测试完整性的一个手段,是测试技术有效性的度量。希望有个平台可以统计整个迭代的后端覆盖率数据报告, 包括接口测试, 功能测试。
测试人员可以通过测试覆盖情况来分析自身测试质量,同时达到最终提升测试质量的目的。
实现原理
网上对于jacoco的实现原理有非常详细的解释说明,这里就不再赘述,简单来说就是要拿到三件套——源码、class和jacoco.exec,那么就可以实现一个应用的代码覆盖率统计。
而覆盖率平台是基于jacoco二次开发,统计自动化测试和手动测试的覆盖率数据。
系统设计方案:
CI自动触发
moon每次部署完成,触发接口自动化测试,集成测试平台会向通知覆盖率平台去收集该次接口自动化测试的覆盖率数据,本次执行完的jacoco数据单独存放和本次执行记录挂钩。
手动触发
moon每次部署完成,在Webhook对应的环境下会触发请求,通知覆盖率平台去收集该次手工测试的覆盖率数据,直至下一次部署为止,每10分钟更新一次数据,本次执行jacoco数据单独存放和本次执行记录挂钩。(注:手工测试的覆盖率收集需要先在平台上新建对应应用的job,否则会触发收集失败)
Jacoco特性
首先我们来看一下jacoco是如何注入代码实现收集覆盖率的。
如上图所示,jacoco在java代码中插入探针,每个探测指针都是一个BOOL变量(true表示执行、false表示没有执行),程序运行时通过改变指针的结果来检测代码的执行情况(不会改变原代码的行为)。
所以,只要利用jacoco的插桩的特性,可以准确获得测试过程中,代码的执行情况,而覆盖率平台正是利用这种特性,来收集各个应用的覆盖率。
我们设计的方案也是基于 JaCoCo 做相应改造,生成我们所需要的覆盖率模型,并通过 JaCoCo 开放的 API 实现相关功能。
全量覆盖率
全量覆盖率的实现非常简单,只要拿到上述的三件套,就可以完成一个全量代码覆盖率的收集。
可以拆分成如下几个步骤:
1. 获取测试完成后的 exec 文件(二进制文件,里面有探针的覆盖执行信息, 记录了代码的覆盖情况)
2. 获得本次部署的镜像,拿到插桩后的classes
3. 获取基线提交的代码(本次部署的gitlab上对应commit的代码)
4. 利用jacoco的api生成报告
增量覆盖率
在平时测试过程中,如果是需要看一个应用本次发布的代码和某一次发布的代码之间的差异点是否都已经测试到了,那么我们的关注点就不在于全量的覆盖情况,而是一个增量覆盖,也就是本次的代码和上一次的代码改动量的覆盖情况。我们可以通过改造上述三件套,来实现这样的需求。
具体步骤如下:
1. 获取测试完成后的 exec 文件(二进制文件,里面有探针的覆盖执行信息, 记录了代码的覆盖情况)
2. 获得本次部署的镜像,拿到插桩后的classes
3. 获取基线提交与被测提交之间的差异代码
3. 对差异代码进行解析,切割为更小的颗粒度,选择类为最小维度(后续为了精准测试,需要将最小颗粒度精确到方法)
4. 改造 JaCoCo ,使它支持仅对差异代码生成覆盖率报告
docker上获取classes
因为每次应用部署后,都会把镜像文件push到镜像仓库,镜像文件里打包了插桩后的class文件,所以要获得class,需要从镜像仓库中把对应的部署产生的classes拉取下来。
其中在moon上部署的应用,moon默认在webhook上调用kuafu的接口,会把相应的镜像名等信息回传给覆盖率平台;而持续集成平台上跑的自动化测试构建的环境,也会调用kuafu接口回传镜像名称。因此,覆盖率平台只需要根据镜像名称,docker pull 把镜像拉去下来解析出class即可。
镜像文件中是打包好的应用的jar包,解压后遍历文件夹,找到classes文件夹,该文件夹下的就是所有插桩后的classes。
这里用的是github上开源的一个封装好的docker工具——com.github.dockerjava.api,通过这个工具类可以非常方便的操作docker。
核心代码如下:
//执行docker命令
public String execCommand(String containerId, String[] command) {
DockerClient dockerClient = createDockerClient();
ExecCreateCmdResponse exec = dockerClient.execCreateCmd(containerId).withCmd(command).withTty(false).withAttachStdin(true).withAttachStdout(true).withAttachStderr(true).exec();
OutputStream outputStream = new ByteArrayOutputStream();
String output = null;
try {
dockerClient.execStartCmd(exec.getId()).withDetach(false).withTty(true).exec(new ExecStartResultCallback(outputStream, System.err)).awaitCompletion();
output = outputStream.toString();// IOUtils.toString(outputStream, Charset.defaultCharset());
} catch (InterruptedException e) {
log.warn("Exception executing command {} on container {}", Arrays.toString(command), containerId, e);
}
try {
dockerClient.close();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
log.error(e.getMessage());
}
return output;
}
获取exec
我们获取 exec 文件是通过 tcp 方式获取的,在部署 java 应用服务时,指定了 -javaagent 参数的 output 为 tcpserver ,并指定可用端口,所以 javaagent 参数设定如下: output=tcpserver,address=0.0.0.0,port=6300
,然后将 javaagent 参数注入 JVM ,这就是为什么我们要能收集到数据,就必须在moon上注入这段神秘代码。
以上步骤完成以后,在我们工具内就可以通过 JaCoCo 开放出来的 API 进行 exec 文件获取,部分代码片段如下:
// Open a socket to the coverage agent:
final Socket socket = new Socket(InetAddress.getByName(ip), PORT);
try {
final RemoteControlWriter writer = new RemoteControlWriter(socket.getOutputStream());
final RemoteControlReader reader = new RemoteControlReader(socket.getInputStream());
reader.setSessionInfoVisitor(localWriter);
reader.setExecutionDataVisitor(localWriter);
// Send a dump command and read the response:
writer.visitDumpCommand(true, false);
if (!reader.read()) {
throw new IOException("Socket closed unexpectedly.");
}
} catch (Exception e) {
// TODO: handle exception
log.error("socket connect error:{}", e.getMessage());
} finally {
socket.close();
localFile.close();
}
生成覆盖率报告
这步主要是用 JaCoCo 开放的 API 和来实现的,根据前两步获取到的 class 和源码信息,用 JaCoCo 的api去解析 exec 文件,核心代码如下:
/**
* Create a new generator based for the given project.
*
* @param projectDirectory
*/
public ReportGenerator(final File projectDirectory) {
this.title = projectDirectory.getName();
this.executionDataFile = new File(projectDirectory, "jacoco-server.exec");
this.classesDirectory = new File(projectDirectory, "bin");
this.sourceDirectory = new File(projectDirectory, "src");
this.reportDirectory = new File(projectDirectory, "coveragereport");
}
/**
* Create the report.return bundle for analyse cover info
*
* @throws IOException
*/
public IBundleCoverage createByBundle() throws IOException {
// Read the jacoco.exec file. Multiple data files could be merged
// at this point
loadExecutionData();
// Run the structure analyzer on a single class folder to build up
// the coverage model. The process would be similar if your classes
// were in a jar file. Typically you would create a bundle for each
// class folder and each jar you want in your report. If you have
// more than one bundle you will need to add a grouping node to your
// report
final IBundleCoverage bundleCoverage = analyzeStructure();
createReport(bundleCoverage);
return bundleCoverage;
}
//单module的报告生成
private IBundleCoverage analyzeStructure() throws IOException {
final CoverageBuilder coverageBuilder = new CoverageBuilder();
final Analyzer analyzer = new Analyzer(
execFileLoader.getExecutionDataStore(), coverageBuilder);
analyzer.analyzeAll(classesDirectory);
return coverageBuilder.getBundle(title);
}
//多module的报告生成
private ISourceFileLocator getSourceLocator(File sourceFileStart) {
List<File> sourceFileList = getSourceFileList(sourceFileStart);
final MultiSourceFileLocator multi = new MultiSourceFileLocator(
4);
for (final File f : sourceFileList) {
logger.info("f.getAbsolutePath():" + f.getAbsolutePath());
multi.add(new DirectorySourceFileLocator(new File(f.getAbsolutePath()), "utf-8", 4));
}
return multi;
}
解析差异代码
gitLab支持比较两个commit版本之间的差异代码,这里我们使用org.gitlab.api的工具类,通过方法compareCommits可以得到两个版本的差异代码,其中包含了差异代码的具体内容、行号、文件名等等信息。
因为生成覆盖率报告需要类文件、源码和jacoco文件这三个要素,而生成的覆盖率报告依类文件而定。举个栗子:应用J含有A、B、C三个类,通过上一节我们知道需要把三个元素放在对应的文件夹下,在生成覆盖率报告的时候把A、B、C都放到类文件夹下,则最终生成的报告将会包含这三个类;如果只把A放到类文件夹下,那么最终生成的报告就只含有A这个类,没有B和C。
那么,要获得精确到类的增量覆盖率,只需要把全量的类文件替换成差异的类文件即可。通过得到的差异代码,可以获得相应的类名,此时遍历全量类文件,找到相关类名和相对路径,拷贝到生成覆盖率报告的类文件夹下。
核心代码如下:
if (toCommitId != fromCommitId) {
Set<String> javaModifyFileList = new HashSet<String>();
String pattern = ".*.java";
//获得两个commit间的差异信息
GitlabCommitComparison gitlabCommitComparison = gitlabService.getCompare(projectId, fromCommitId,
toCommitId);
//遍历差异信息
gitlabCommitComparison.getDiffs().forEach(item -> {
if (Pattern.matches(pattern, item.getNewPath())) {
javaModifyFileList.add(item.getNewPath());
List<Integer> modifyLineNum = new ArrayList<Integer>();
log.info("pc diff: {}", item.getDiff());
//从全量的类文件夹下开始遍历
Path classStart = Paths.get(fcClassPath);
try (Stream<Path> stream = Files.find(classStart, maxDepth, (path, attr) -> String.valueOf(path)
.endsWith(StringUtils.substringBeforeLast(newFileName, ".java") + ".class"))) {
String joined = stream.sorted().map(String::valueOf).collect(Collectors.joining("; "));
log.info("pc Found: {}", joined);
log.info("pcClassPath:{}", pcClassPath);
//复制到生成覆盖率报告的类文件夹下
FileUtils.copyFileToDirectory(new File(joined), new File(pcClassPath));
} catch (IOException e2) {
// TODO Auto-generated catch block
e2.printStackTrace();
}
}
});
}
//api获得git上的差异代码
@Override
public GitlabCommitComparison getCompare(final Integer projectId, final String oldCommitHash,
final String newCommitHash) {
final GitlabAPI api = createApi();
Pagination pagination = new Pagination();
pagination.setPage(1);
pagination.setPerPage(20);
GitlabCommitComparison gitlabCommitDiff = null;
try {
gitlabCommitDiff = api.compareCommits(projectId, oldCommitHash, newCommitHash, pagination);
} catch (IOException e) {
log.error(e.getMessage());
}
return gitlabCommitDiff;
}
查看数据
全量覆盖
点击全量可以查看到基于全量代码的覆盖率详情,具体通过报告内容可以分析代码覆盖情况:绿色的表示完全覆盖,红色的表示完全没覆盖,黄色的表示部分分支覆盖(点击四角形可以看到覆盖的分支数)
生产增量覆盖
生产增量统计的是本次部署,和生产环境下对应的增量代码的覆盖率情况,同时接入了精准测试平台,可以查看影响的接口,更有助于直观的分析测试质量。
点击精准测试即可转入精准测试平台,同时点击类名(方法)也可以跳转到覆盖率平台对应的该方法上。
规划
和用例平台打通,结合精准测试的调用链分析功能,将覆盖粒度精确到函数级别,可以获得每条用例覆盖到的函数和影响到的接口,通过更小的维度更精准地度量测试质量。
和流量回放平台打通,测试童鞋每次跑一些流量,获得相应的覆盖代码。当所有的流量回放完,测试童鞋可以比对每次回放覆盖到的代码,从而筛选出每次都未覆盖到的废代码(当然这块判定废代码的步骤需要测试童鞋斟酌),从而提高开发代码质量。